feat(bank): integrate Bank Frick WebAPI#4196
Conversation
Hard requirement: 100% test coverage for the new Bank Frick codeThis PR touches inbound statement import and outbound fiat payouts — money movement with double-spend exposure. For this surface, 100% test coverage of the new Bank Frick code is a hard requirement (statements, branches, functions, lines) before it can be considered ready. Nice-to-have paths and defensive The current suite is genuinely strong on the critical happy/error paths (RSA-exact-bytes signing, JWT cache + single 401 retry, fail-closed camt parsing + non-advancing watermark, idempotent-payout collision check, liquidity reservation). But coverage is not yet 100%, and right now nothing enforces it: there is no 1. Make it enforceable (so it can't silently regress)
2. Close the current gapsConcrete branches/paths that still need tests:
Once the threshold is in place and green, the 100% number is self-documenting and this requirement is met. |
Follow-up: the coverage gate is in place but scoped too narrowlyThanks for wiring up the enforcement — The remaining gap: the gate only covers two of the new Frick files. Both the
and the script runs only
The hard requirement was 100% of the new Bank Frick code, and it's precisely the payout finalizing state machine (fail-closed guards on money movement) that most needs to be locked down. Please extend both the |
TaprootFreak
left a comment
There was a problem hiding this comment.
Summary
PR #4196 delivers a carefully engineered Bank Frick WebAPI rail: RSA-SHA512 exact-body signing, JWT caching with single-flight auth, customId-based payout recovery (including BOOKED historical lookup), dual kill switches, liquidity reservation until terminal Frick states, and shared CAMT parser hardening used by Raiffeisen/Yapeal.
Independent re-verification of the two original “bug” findings:
- Missing
fromDateon non-BOOKED lookup → downgraded to suggestion (doc default −30d is real, but PREPARED expires bank-side after 7 days unsigned; not a plausible money-loss path under normal hourly polling). - SEPA Instant omitting
express→ false positive / withdrawn: Bank Frick human docs forbid sendingexpressforSEPA_INSTANT(validation error if sent); code and tests match the official example.
Remaining findings are operational hygiene, coverage-gate scope, liquidity edge cases, and shared-CAMT rollout residual risk. Client layer looks solid with focused CI coverage on frick.service.ts/frick.dto.ts.
Issue counts by severity
- bugs: 0
- suggestions: 6
- nits: 1
Verification note
A separate adversarial verification pass re-checked both original severity=bug findings against code, call paths, OpenAPI, and Bank Frick human docs (incl. SEPA Instant section and PREPARED→EXPIRED in 7 days). Only the corrected severities below are posted as inline comments.
Round 2 addressed: full-surface coverage gate + all review suggestionsThanks for the precise follow-up — the gate scope and every suggestion from the review are now implemented as of Coverage gate (the hard requirement). Rather than adding the four shared files wholesale to the threshold map —
Suggestions and nit:
Validation for |
Automated repair triggered by Ultra Test-Agent PR Guard. Previous head: 77eb99e
|
TaprootFreak
left a comment
There was a problem hiding this comment.
Independent deep review — 1 blocking, 11 major, 12 minor
Reviewed at head 77eb99e13 against merge-base e4bd5316b. Method: 11 parallel finder lanes over the full diff, each candidate verified adversarially by three independent lenses (refute / reproduce / impact), then a completeness critic and a second round; 49 candidates raised, 27 survived. I hand-verified every money-path finding against the production data and the pre-existing bank rows, and dropped one major candidate as empirically false (last section) — the earlier review fixes are real and I want to be precise about what does not break.
The three failure modes this repo has actually been bitten by are all clean. The migration adds no foreign key, so it cannot hit the known missing-PK drift. SettingRepository.setDateMax uses an advisory lock plus a TypeORM save() rather than a raw () => '…' expression, so there is no unquoted-camelCase Postgres crash. buildFrickConfig never throws, so deploying with no FRICK_* values cannot crash-loop the API. Both ON CONFLICT targets are backed by real constraints, and the seeded disabled-process strings match the Process enum values exactly. The default-off claim holds — this PR is safe to merge in the narrow sense that merging it does not break production today.
What it is not yet is activatable. The findings below cluster almost entirely in what happens the day Operations turn the rail on, and the first one takes the existing payout rails down with it.
Throughout, I used Yapeal as the reference implementation, since it is the closest existing analogue. Most findings below are places where Frick silently departs from the Yapeal pattern.
Blocking
1. Activating Frick breaks payouts for the bank that is already live
src/subdomains/supporting/fiat-output/fiat-output-job.service.ts:159-168
const banks = await this.bankService.getSenderBanks(currency); // { currency, send: true }
const eligibleBanks = banks.filter(…);
if (eligibleBanks.length > 1 && eligibleBanks.some((c) => c.name === IbanBankName.FRICK))
throw new Error(`Ambiguous sender bank configuration for ${currency}`);Production today has Olkypay EUR with send=true and Yapeal CHF with send=true. The moment Operations set send=true on the Frick EUR row and enable transmission (exactly what docs/bank-frick-operations.md §3 instructs), getSenderBanks('EUR') returns two eligible banks, one of which is Frick → this throws.
It throws for every EUR fiat_output, not just the ones intended for Frick. The exception is caught by the per-entity handler in assignBankAccount() (:206), which only logs Error in fillPreValutaDate fiatOutput: <id> — so the row keeps bank = NULL / accountIban = NULL, is re-tried every minute, and is never paid out. The same applies to CHF against Yapeal. Customer sell payouts silently stop while the only signal is a repeating log line.
The kill switch does not save you either: canCreatePayments() is part of the eligibility filter, so the throw arms itself precisely when the rail is switched on.
This is a fail-closed guard that was added in response to an earlier review comment, but it encodes "two sender banks per currency is a misconfiguration" — while the PR description explicitly plans for coexistence ("sender priority … are operational inputs"). Both cannot be true.
Fix: implement the sender priority rather than throwing on plurality. A deterministic selector (an explicit priority/order column on bank, or an explicit "Frick takes precedence for currency X" rule) that returns exactly one bank and never throws for a currency that has a working incumbent. If a guard is kept, it must fire only when Frick itself is ambiguous, never when Frick merely coexists with Olkypay/Yapeal. This needs a test asserting that an EUR payout still routes to Olkypay while Frick EUR is send=true.
Major
2. Legacy Bank Frick rows make (name, currency) non-unique
migration/1783944000000-AddBankFrickPayoutTracking.js:27-36
Bank Frick was integrated once before and removed (cdd6824df Remove Bank Frick implementation (#2733), cf933e4cd), but its three bank rows were never cleaned up — production still holds Bank Frick EUR/CHF/USD (ids 1/2/3, all receive=false/send=false, carrying the old account's IBANs). Adding rows for the new account is correct — it is a genuinely different account — but it leaves ('Bank Frick', 'EUR') and ('Bank Frick', 'CHF') matching two rows each.
BankService.getBankInternal(name, currency) is a findOneCachedBy({ name, currency }) — a findOne with no ordering — so it now returns an arbitrary one of the two, cached per process. BankService.loadIbanCache() (:106-113) keeps the first row per ${name}-${currency} key, which will be the stale legacy IBAN. Any future consumer that resolves Frick by name+currency (the liquidity BankAdapter does exactly this at bank.adapter.ts:92) can silently bind to the dead account.
Fix: as part of this PR, retire the legacy rows — delete them, or mark them unambiguously (e.g. rename to Bank Frick (legacy)) so (name, currency) stays unique for the active account. Leaving two rows per currency and relying on receive/send flags to disambiguate is exactly the kind of implicit coupling that produces finding #10.
3. Synthetic IBANs are published by an unauthenticated endpoint
migration/…:31, migration/seed/bank.csv:2-3
The placeholder IBANs (LI…FRICKCHF0001 / LI…FRICKEUR0001) are mod-97 checksum-valid, so they pass every IBAN validator. GET /v1/bank is public (verified: HTTP 200, no auth) and unfiltered — BankController.getAllBanks() → bankRepo.findCached('all') applies no receive/send predicate, and BankDto exposes only name/iban/bic/currency, so no consumer can distinguish a placeholder from a live account. Between merge and the Operations follow-up, that endpoint would serve five Bank Frick entries — three legacy, two invented.
Fix: the real IBANs for the new account exist. Put them in the migration and the seed directly instead of fabricating placeholders, keeping receive/send/sctInst false until sign-off — the flags, not the IBAN, are the safety mechanism. That also removes the §3 step ("replace each synthetic IBAN") entirely, which is the step most likely to be executed wrongly under pressure. Note bank.csv also assigns ids 17/18, which collide with a different bank's id in production.
4. One malformed camt.053 entry stalls the entire inbound import
src/integration/bank/services/iso20022.service.ts:246
In strict mode a single unparseable entry (an empty <EndToEndId/>, a Strd that is not CdtrRefInf) makes parseCamt053Xml throw for the whole statement. Zero transactions are returned, the watermark is not advanced, and the statement is refetched forever — so every other, well-formed customer deposit booked on that account in the same window is never imported and never credited. The only signal is a repeating log line.
Fix: fail the entry, not the statement. Separate money-critical validators (amount, currency, CdtDbtInd, status, IBAN → reject the entry) from cosmetic ones (empty optional element, unsupported Strd → drop the field). At minimum parseOptionalCamtString must treat an empty optional element as undefined.
5. Reference-less entries get a content-derived dedup key → double crediting
src/integration/bank/services/iso20022.service.ts:329
When AcctSvcrRef/TxId/NtryRef are all absent, the bank_tx identity becomes a hash over the entry's own content. Any bank-side amendment inside the 2-day overlap window (an amended AddtlNtryInf narrative, a new optional element in a future Frick camt release) changes the hash → findOneBy misses → a second bank_tx for the same real money → assignTransactions() matches the same bank usage again → the customer is credited twice. A serialization change on the bank's side does this to every reference-less entry at once.
Fix: fail closed on a missing bank reference in strict mode (throw, or quarantine the entry) rather than synthesizing an identity from the payload. If a synthetic reference is truly unavoidable, derive it only from immutable money-identifying fields (accountIban, booking date, amount, currency, indicator, endToEndId) plus an occurrence index — never from the whole entry object.
6. Payout idempotency is a non-atomic read-before-write → double payout
src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts:96-120
Nothing is persisted before the PUT /transactions. If the response is lost (gateway timeout, pod restart, response-signature failure), the catch writes only frickError and frickTxId stays NULL. The next tick re-selects the row; if the bank's transaction search does not yet return the just-created order, getPaymentOrderOrUndefined returns undefined and a second payment order is PUT for the same customId. The same double-PUT happens deterministically with two API instances: both read frickTxId IS NULL, both find no order, both PUT.
Fix: reserve the payout in the DB before any HTTP call — a conditional UPDATE fiat_output SET "frickTxId" = :customId, "isTransmittedDate" = now() WHERE id = :id AND "frickTxId" IS NULL, proceeding only if exactly one row was affected. After a send with an unknown outcome, a retry must not treat a lookup miss as "no order exists".
7. The status=BOOKED lookup can never return an order
src/integration/bank/services/frick.service.ts:337
Bank Frick's BOOKED transaction objects carry no customId and no type, but the strict validator requires both — so getPaymentOrder() throws for every settled payout, which is the normal success path. frickOrderStatus never becomes BOOKED, isApprovedDate/isConfirmedDate are never set from the status path, and the order is re-fetched and re-error-logged hourly forever. It also breaks the idempotency lookup in #6: instead of recognising an already-booked order, it raises a permanent exception. This is a regression introduced by the fromDate fix commit; a regression test feeding the spec's BOOKED example JSON through getPaymentOrder() would have caught it.
8. Booked debits include bank charges, so a charged payout never reconciles
src/integration/bank/services/frick.service.ts:148
bank_tx.amount is set to the booked CAMT entry amount (charges included) and chargeAmount is hard-coded to 0, while getUniqueOutgoingBankTx requires the amount to equal fiat_output.amount within 0.5 cents. fiat_output.charge is NULL for every row in production, so outputCharge = entity.charge ?? SHA (fiat-output-frick.service.ts:87) means every CHF Frick payout goes out SHA. A 1,000.00 CHF payout booked as Amt=1005.00, Chrgs=5.00 therefore never matches: isComplete stays false, the linked buy_fiat never completes, and — because BOOKED is deliberately non-terminal — the 1,000 CHF stays in pendingBalance permanently, shrinking the account's available balance until the rail stops readying any payout at all.
Fix: parse NtryDtls/TxDtls/Amt (falling back to AmtDtls/InstdAmt) as the amount and parse entry-level Ntry/Chrgs into chargeAmount/chargeCurrency for debit entries, as the existing SepaParser already does, instead of hard-coding chargeAmount: 0 — a silent default masking a wrong monetary value. Alternatively match on amount - chargeAmount.
9. Frick liquidity balance is never refreshed — getBalances() is dead code
src/integration/bank/services/frick.service.ts:47
BankAdapter.getForBank() has case IbanBankName.OLKY and case IbanBankName.YAPEAL — but no case FRICK, even though BankFrickService.getBalances() is fully written and has exactly the same shape as the Yapeal one (per-currency availableBalance). It has zero callers.
Following the runbook therefore yields one of two outcomes, both bad. With no LiquidityBalance row, asset.balance.amount throws a TypeError swallowed by the catch at fiat-output-job.service.ts:320 → no Frick payout ever gets an isReadyDate and the rail silently does nothing. If Operations seed the row by hand (the only way to satisfy the runbook), it is never updated again → every payout is gated on a permanently stale balance, and successive payouts release against the same figure until the account is overdrawn.
Fix: add the case IbanBankName.FRICK: to BankAdapter.getForBank() mirroring the Yapeal branch, and register BankFrickService with the liquidity module. Otherwise delete getBalances(), validateAccountsResponse() and the balance DTOs as dead code and correct §3 step 2, which promises a refresh that does not exist.
10. A send=true / receive=false Frick account deadlocks silently
src/subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts:41
Reconciliation depends on the inbound receive flag: checkTransactions() skips any bank row with receive === false, so a payout-only account never gets its camt.053 imported, the booked debit never reaches bank_tx, isComplete is never set, and the liquidity reservation grows without bound until the rail stops paying out. No error, no log, no monitor — and the coupling is documented nowhere, while the account-role matrix in the runbook actively invites this configuration.
Fix: fail closed instead of relying on an operator's reading of a flag name — reject a Frick row with send === true && receive === false with a loud error (e.g. a Bank.isReconcilable getter), and state in §3 that a Frick row used for send=true must also have receive=true, with its watermark seeded first.
11. Terminally failed orders are silently abandoned and the reason is erased
src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts:203
On REJECTED / EXPIRED / DELETED / ERROR the poller writes frickOrderStatus=<state>, frickError=null — deleting the reason — releases the liquidity, stops polling, and never retries. isComplete stays false forever. The stuckFiatOutputs monitor does not look at these rows, so a customer's rejected payout sits unpaid and undetected until someone queries for it manually.
Fix: persist the failure reason instead of erasing it, and surface these rows — extend the stuckFiatOutputs monitor to count frickOrderStatus IN (<terminal>) AND isComplete = false, or raise a notification.
12. transmitPayments() destructively overwrites the customer-facing remittanceInfo
src/subdomains/supporting/fiat-output/fiat-output-frick.service.ts:122 (+ :154)
createUniqueReference() builds "DFX-FO-<id> <original>", truncates to 140 chars, and writes the result back over fiat_output.remittanceInfo. The original is kept nowhere — with a long original it is not even recoverable as a substring. The field is still read downstream: chain-report-history-dto.mapper.ts:327 uses it as the txid in the customer's chain report, which therefore changes retroactively.
The overwrite is also avoidable: frickTxId already holds the customId separately, so the matcher can key on that (or on a startsWith(customId) reference echo) without destroying the original.
Fix: keep remittanceInfo intact; store the reference actually sent to the bank in its own column.
Minor
fiat-output-job.service.ts:162— an instant CHF payout can be routed to a Frick CHF row, but CHF goes out asFOREIGNandcreatePaymentOrderthrowsBank Frick instant payments are only supported for EUR(frick.service.ts:204) → endless per-minute transmit-failure loop.http.service.ts:211— the "raw response bytes" the detached signature is verified over are not raw: axios strips a UTF-8 BOM and lossily transcodes non-UTF-8 bytes before the verifier sees them, so a legitimately signed response can fail verification permanently. UseresponseType: 'arraybuffer'and verify over theBuffer.frick.service.ts:468— a failed response-signature verification (i.e. a detected tampered bank response) is reported to operators as the indistinguishable stringrequest failed, defeating detection of the exact attack the signature check exists for. Give it a distinct error.frick.service.ts:447— no HTTP timeout on any Bank Frick request; combined with an unbounded cron lock, a single hung connection kills the payout status poller permanently and silently.fiat-output-frick.service.ts:87— the fee-bearingchargefield is silently defaulted to SHA for every CHF payout (no-silent-fallback rule); the beneficiary then receives less thanfiat_output.amount. Make it explicit.fiat-output-job.service.ts:560— a payout that can never be matched (or whose match is ambiguous) fails closed but is retried and re-logged forever with no escalation; liquidity stays reserved and the stuck-payout monitor is blind to it.migration/…:27— the id-lessINSERT INTO "bank"is the first ever consumer ofbank_id_seqin production; a lagging sequence yields a PK collision thatON CONFLICT ("iban","bic")does not catch, aborting the migration.docs/bank-frick-operations.md:64— §2 tells Operations to expect a batch of duplicatebank_txrows on the live Raiffeisen/Yapeal accounts and to plan a manual dedup pass over real money rows, but no production code path createsbank_txfrom either camt parser, so that batch cannot occur. Inviting a manual cleanup over real rows for a non-existent event is worse than saying nothing.http.service.ts:24— a stateful Bank Frick mock simulator (module-globalMap,any, no return types) was added to the shared productionHttpService. Wrong layer, and the state leaks across tests.package.json:193— the new 100%coverageThresholdon the sharediso20022.service.tswill turn unrelated future PRs red.fiat-output.entity.ts:157— names do not reflect content:frickTxIdstores DFX's owncustomId, andfrickOrderIdstores a number asvarchar(256).bank.service.ts:51—getSenderBank()is left behind with zero production callers.
On the coverage gate. No test executes the new raw SQL against a real Postgres (the PR ships a real-PG harness and CI already provides the DB), and no test parses a real Bank Frick camt.053 — the strict "contract" suite bypasses the XML parser entirely and the single XML fixture is hand-rolled to fit the parser. The 100% gate proves the branches execute, not that they are right: findings #7 and #8 both sit inside fully "covered" code.
Withdrawn — raised, checked, and found not to be a defect
Two of my lanes independently flagged the new isReadyDate: Not(IsNull()) filter in searchOutgoingBankTx() (fiat-output-job.service.ts:531), claiming it permanently excludes manually executed payouts of all banks from reconciliation. That is empirically false and I am dropping it. In production every fiat_output receives an isReadyDate within minutes of creation, and across the entire completed history not one row was ever reconciled without it (zero rows with outputDate IS NOT NULL AND isReadyDate IS NULL). Only two long-stuck rows would leave the candidate set. Relatedly, the old plausibility check isReadyDate > bankTx.created was not dropped — it moved into the matcher as earliestDate, alongside new amount/currency/IBAN/direction constraints. The outgoing matcher is strictly tighter than what it replaced.
Summary
The engineering standard is high, and the parts that usually go wrong here went right: the crypto boundary, the fail-closed validators, the kill switches, the watermark logic, and — importantly — the three classes of change that have actually taken this API down in the past are all avoided. Merging this does not break production.
The problem is that the rail cannot be turned on as designed. #1 stops the existing EUR/CHF payouts the moment Frick is enabled; #9 means the payout gate runs on a balance nobody refreshes; #7 and #8 mean a successful payout never reaches a terminal state or reconciles, so liquidity is consumed permanently; #10 turns a plausible account configuration into a silent deadlock. Each of these is individually invisible in production — they surface as money quietly not moving, with a log line at most.
The common thread is that Frick departs from the Yapeal pattern in exactly the places where the pattern carries the safety: no BankAdapter case, no charge parsing, no monitor coverage, and a bespoke ambiguity guard instead of a deterministic sender selection. Aligning it with Yapeal end to end would resolve #1, #8, #9 and #11 together.
Follow-up: repo precedent for
|
Correction to my previous comment: the seed CSV should stay syntheticOne thing in my follow-up was wrong, and I would rather correct it than leave a bad instruction standing in the thread. I suggested putting the real Bank Frick IBANs into So the corrected position is narrower than what I wrote, and it isolates the actual defect:
That keeps the fabricated IBANs out of production (and out of the unauthenticated |
Closing the seed-CSV point: align the placeholders with the Yapeal patternTo settle this concretely rather than leave it as an opinion, here is what
So it is 4 real / 4 synthetic, but that split is misleading: the four real ones are stale rows filed under the wrong bank name. They are not a convention to follow. The two rows that are actually maintained and correctly named — Yapeal, the closest analogue to Frick — are synthetic. Synthetic placeholders are right, and this PR is right to use them. There is one detail worth aligning, though. The Yapeal placeholders keep the real bank clearing number and only fake the account part: The Frick placeholders zero that out too ( Consistent with Yapeal: 18,…,Bank Frick,LI17088110FRICKEUR001,BFRILI22,EUR,FALSE,FALSE,FALSE,TRUE,
17,…,Bank Frick,LI35088110FRICKCHF001,BFRILI22,CHF,FALSE,FALSE,FALSE,TRUE,Both are mod-97 valid, 21 chars (correct LI length), carry Frick's real clearing number This is cosmetic and does not affect any of findings #1–#12. The substantive point next door stands unchanged: the migration should not insert |
Address the independent deep review (1 blocking, 11 major, 12 minor), aligning Bank Frick with the Yapeal reference pattern throughout. - Sender selection: data-driven sendPriority tie-breaker instead of throwing on plurality (throw only on a genuine Frick priority tie) - camt.053: reject malformed entries per-entry instead of failing the whole statement; fail closed on a missing bank reference in strict mode - Payout idempotency: atomic reservation before transmit, self-heal on a definitive not-found - BOOKED lookup no longer requires fields Bank Frick never sends; reconcile net of booked charges - Add the Frick liquidity-balance case; reconcilability guard for send-only rows; preserve terminal failure reasons; HTTP timeouts - Keep the customer-facing remittanceInfo (bank reference lives in frickReference); verify signed responses over raw bytes - Migration no longer inserts bank rows; new account and legacy cleanup are documented manual production steps
Re-review of the fix commits (
|
The prior fix commits introduced four new defects the re-review caught; resolve them. - Scope the stuckFiatOutputs health clause to Frick, so a deploy no longer reports DEGRADED from pre-existing, long-settled non-Frick payouts - Fold frickReference into the atomic payout reservation and re-heal it in the status poller, so a crash between the two writes cannot strand reconciliation - Make the accounting charge direction-aware (a debit amount is already gross) to match the net-of-charge outgoing reconciliation and stop double-counting the charge - Make the not-found self-heal clear conditional, so an overlapping transmit cannot blind the status poller by clearing a just-reserved row Regression test per defect (each fails against the pre-fix code); full suite green, Frick coverage stays 100%.
|
Addressed the re-review of the fix commits ( The four new defects the re-review found in the fix commits are resolved:
Each fix has a regression test that fails against the pre-fix code. Full suite green, Bank Frick coverage stays at 100%, and the final review over the whole PR found no outstanding correctness or conformity issues. One item to confirm before merge/activation — accounting field only, no money flow or double-payout: NEW-3 and #8 both rest on the assumption that Bank Frick books debit entries gross (charge-inclusive), which is already on the activation checklist for sandbox confirmation. Because the accounting change ( |
Move the 100% coverageThreshold out of the shared package.json jest block into a dedicated jest.frick.config.js used only by test:frick:cov, so the strict per-file threshold on the shared iso20022.service.ts can no longer red an unrelated test:cov run. Behaviour-neutral for the Frick gate (same 7 files, same 100%, same CI step); also add the new root config to eslint ignores alongside the existing root configs.
|
Follow-up consistency improvement ( Isolated the Bank Frick 100% coverage gate into its own Behaviour-neutral for the Frick gate: same seven files, same 100%, enforced in the same CI step. Verified directly — dropping a covered spec still reds the gate ( A separate dead-guard cleanup was considered and deliberately not taken — that guard turned out to be a tested defense-in-depth layer on the reconciliation path, so it was kept. |
TaprootFreak
left a comment
There was a problem hiding this comment.
Approving. I re-verified the fix commit (d3200b8ed) against my three re-review findings against the code (and the CAMT parser) — all three are correctly resolved.
- NEW-1 (health DEGRADED on deploy):
stuckFiatOutputsclause 3 is now scoped withfrickCustomId: Not(IsNull()). Pre-existing non-Frick rows (frickCustomId NULL) no longer match, so/healthwon't flip to DEGRADED on deploy, while genuine transmitted-but-stuck Frick payouts (>48h) are still caught. The observer spec evaluates the real clause array and fails against the pre-fix generic clause — not tautological. - NEW-2 (reconciliation strand):
frickReferenceis folded into the atomic reserve ({ frickCustomId: customId, frickReference }) and removed from the deferred post-create write, so a crash between the two writes can no longer leavefrickReferenceNULL while the order is live at the bank. The status-poller gap-heal recomputes the identical deterministic reference (frickCustomId is only ever the deterministic customId), so it cannot diverge. - NEW-3 (charge sign): Confirmed against
iso20022.service.ts:289-294— a charge is parsed only on DEBIT entries and the booked DEBIT amount is gross (charge-inclusive). The matcher subtractschargeAmountfor net matching, and accounting no longer adds it back on debits (accountingCharge = CREDIT ? chargeAmount : 0). Matcher and accounting are consistent and correct to the actual parser convention; non-Frick banks (chargeAmount 0) are unaffected. Substitution is complete across all three branches. - NEW-4: the not-found self-heal clear is now conditional — a clear improvement.
One residual, minor, non-blocking for merge — but must be fixed before the rail is armed:
The conditional not-found clear guards on { frickCustomId, isTransmittedDate: IsNull() }. That still matches a row a concurrent minutely transmitPayments has reserved-and-PUT but not yet stamped with isTransmittedDate. In that narrow window (overlapping hourly poll + minutely transmit) the clear can null frickCustomId on a row whose order is already live at Bank Frick, stranding it — and it would be invisible to all three stuckFiatOutputs clauses. It is fail-closed at the bank (createPaymentOrder's lookup-before-PUT + assertSamePayment prevent any double payment) and strictly better than the pre-fix unconditional clear, so it is not a regression.
Note the naive fix (guard on frickReference IS NULL) does not work: the reserve now always sets frickReference, so that guard would never release a genuinely-failed reservation. A durable fix needs a marker that distinguishes "being transmitted right now" from "reserved but abandoned" — e.g. stamp a transmitting-marker in the reserve write and have the clear exclude it, or re-check order existence immediately before the clear.
Because this is only reachable once FRICK_PAYOUT_ENABLED=true, it is safe to merge and deploy now (arms nothing) and close before arming.
CI green on 6af30068 (incl. Build and test).
Summary
Implements #4193 as a default-off Bank Frick WebAPI rail:
camt.053statements intobank_txwith strict parsing, deterministic references, per-account cursors and existing dedup/assignment behaviorreceive=false,send=false,sctInst=falseuntil Operations supplies and verifies the private account dataSecurity and correctness
FRICK_SERVER_PUBLIC_KEYcustomId, semantic collision checks andfromDate=1970-01-01on every active and BOOKED recovery lookuplastBankFrickDate:<bankId>only after a non-empty response was fully persisted, tomax(previous, min(now, latest booking date) - 2 days); the update is atomic and monotonic, while empty, malformed and partially persisted responses never advance itDELETION_REQUESTEDPREPAREDorders from the new-payment transmission kill switchTaprootFreak review follow-up
All seven inline findings are implemented and regression-tested:
customIdlookup uses a widefromDateDELETION_REQUESTEDremains polled and retains liquidityThe previously raised SEPA-Instant
expressconcern remains correctly withdrawn: Bank Frick's documentedSEPA_INSTANTrequest omits that field.Explicit implementation decisions
camt.053PUT /transactions, allowing EURSEPA/SEPA_INSTANTand CHFFOREIGNwith mandatorySHAcharge handlingsignTransactionWithoutTanonly when explicitly enabled; otherwise the order remainsPREPAREDfor portal/TAN approval and continues to be polledValidation for
77eb99e13npm run format:check,npm run lint,npm run type-checke6e08e55a: 198 suites and 3,225 tests passed; 7 suites / 141 infrastructure-dependent tests skipped; 0 failuresnpm audit --omit=dev --audit-level=critical: no critical findingsgit diff --checkThe existing dependency tree still reports non-critical audit findings (82 high, 93 moderate, 31 low). This PR changes neither dependencies nor the lockfile; claiming a vulnerability-free repository would therefore be inaccurate.
Production activation blockers
The code and automated verification can be merge-ready, but the live rail is intentionally not activatable yet. Before enabling it, Operations must:
FRICK_BASE_URL, API key, private signing key, Bank Frick server verification key and customer number through approved secret channelsreceive/send/sctInstflags and liquidity assetsreceive=trueFOREIGN/SHA, EUR/Instant behavior, TAN/manual and exempt approval, reference echo and unique reconciliationdocs/bank-frick-operations.mdA live Bank Frick sandbox smoke test cannot be performed from repository-only inputs and has not been claimed.
References: Bank Frick WebAPI documentation and official OpenAPI specification.
Refs #4193